import ts from 'typescript';

import { getComponentTagName, isStaticGetter } from '../transform-utils';
import { deriveJSXKey } from './utils';

/**
 * A transformer factory to create a transformer which will add `key`
 * properties to all of the JSX nodes contained inside of a Stencil component's
 * `render` function.
 *
 * This can be thought of as transforming the following:
 *
 * ```tsx
 * class MyComponent {
 *   render() {
 *     <div>hey!</div>
 *   }
 * }
 * ```
 *
 * to this:
 *
 * ```tsx
 * class MyComponent {
 *   render() {
 *     <div key="a-unique-key">hey!</div>
 *   }
 * }
 * ```
 *
 * The inserted keys are generated by {@link deriveJSXKey}.
 *
 * **Note**: this transformer must be run _after_ the
 * `convertDecoratorsToStatic` transformer, since it depends on static getters
 * created by that transformer to determine when to transform a class node.
 *
 * @param transformCtx a transformation context
 * @returns a typescript transformer for inserting keys into JSX nodes
 */
export const performAutomaticKeyInsertion = (transformCtx: ts.TransformationContext): ts.Transformer<ts.SourceFile> => {
  /**
   * This is our outer-most visitor function which serves to locate a class
   * declaration which is also a Stencil component, at which point it hands
   * things over to the next visitor function ({@link findRenderMethodVisitor})
   * which locates the `render` method.
   *
   * @param node a typescript syntax tree node
   * @returns the result of handling the node
   */
  function findClassDeclVisitor(node: ts.Node): ts.VisitResult<ts.Node> {
    if (ts.isClassDeclaration(node)) {
      const tagName = getComponentTagName(node.members.filter(isStaticGetter));
      if (tagName != null) {
        // we've got a class node with an `is` property, which tells us that
        // the class we're dealing with is a Stencil component which has
        // already been through the `convertDecoratorsToStatic` transformer.
        return ts.visitEachChild(node, findRenderMethodVisitor, transformCtx);
      }
    }
    // we either didn't find a class node, or we found a class node without a
    // component tag name, so this is not a stencil component!
    return ts.visitEachChild(node, findClassDeclVisitor, transformCtx);
  }

  /**
   * This middle visitor function is responsible for finding the render method
   * on a Stencil class and then passing off responsibility to the inner-most
   * visitor, which deals with syntax nodes inside the method.
   *
   * @param node a typescript syntax tree node
   * @returns the result of handling the node
   */
  function findRenderMethodVisitor(node: ts.Node): ts.VisitResult<ts.Node> {
    // we want to keep going (to drill down into JSX nodes and transform them)
    // only in particular circumstances:
    //
    // 1. the syntax tree node is a method declaration
    // 2. this method's name is 'render'
    // 3. the method only has a single return statement
    //
    // We want to only keep going if there's a single return statement because
    // if there are multiple return statements inserting keys could cause
    // needless re-renders. If a `render` method looked like this, for
    // instance:
    //
    // ```tsx
    // render() {
    //   if (foo) {
    //     return <div>hey!</div>;
    //   } else {
    //     return <div>hay!</div>;
    //   }
    // }
    // ```
    //
    // Since the `<div>` tags don't have `key` attributes the Stencil vdom will
    // re-use the same div element between re-renders, and will just swap out
    // the children (the text nodes in this case). If our key insertion
    // transformer put unique keys onto each tag then this wouldn't happen any
    // longer.
    if (ts.isMethodDeclaration(node) && node.name.getText() === 'render' && numReturnStatements(node) === 1) {
      return ts.visitEachChild(node, jsxElementVisitor, transformCtx);
    } else {
      return ts.visitEachChild(node, findRenderMethodVisitor, transformCtx);
    }
  }

  /**
   * Our inner-most visitor function. This will edit any JSX nodes that it
   * finds, adding a `key` attribute to them via {@link addKeyAttr}.
   *
   * @param node a typescript syntax tree node
   * @returns the result of handling the node
   */
  function jsxElementVisitor(node: ts.Node): ts.VisitResult<ts.Node> {
    if (ts.isCallExpression(node)) {
      // if there are any JSX nodes which are children of the call expression
      // (i.e. arguments) we don't want to transform them since we can't know
      // _a priori_ what could be done with them at runtime
      return node;
    } else if (ts.isConditionalExpression(node)) {
      // we're going to encounter the same problem here that we encounter with
      // multiple return statements, so we just return the node and don't recur into
      // its children
      return node;
    } else if (isJSXElWithAttrs(node)) {
      return addKeyAttr(node);
    } else {
      return ts.visitEachChild(node, jsxElementVisitor, transformCtx);
    }
  }

  return (tsSourceFile) => {
    return ts.visitEachChild(tsSourceFile, findClassDeclVisitor, transformCtx);
  };
};

/**
 * Count the number of return statements in a {@link ts.MethodDeclaration}
 *
 * @param method the node within which we're going to count `return` statements
 * @returns the number of return statements found
 */
function numReturnStatements(method: ts.MethodDeclaration): number {
  let count = 0;

  function walker(node: ts.Node) {
    for (const child of node.getChildren()) {
      if (ts.isReturnStatement(child)) {
        count++;
      } else {
        walker(child);
      }
    }
  }

  walker(method);

  return count;
}

/**
 * Type guard to see if a TypeScript syntax node is one of the node types which
 * corresponds to a JSX element that can have attributes on it. This is either
 * an opening node, like `<div attr="hey">`, or a 'self-closing' node like
 * `<i class="best" />`.
 *
 * @param node a typescript syntax tree node
 * @returns whether or not the node is JSX node which could have attributes
 */
function isJSXElWithAttrs(node: ts.Node): node is ts.JsxOpeningElement | ts.JsxSelfClosingElement {
  return ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node);
}

/**
 * Given a JSX syntax tree node update it to include a unique key attribute.
 * This will respect any attributes already set on the node, including a
 * pre-existing, user-defined `key` attribute.
 *
 * @param jsxElement a typescript JSX syntax tree node
 * @returns an updated JSX element, with a key added.
 */
function addKeyAttr(
  jsxElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
): ts.JsxOpeningElement | ts.JsxSelfClosingElement {
  if (jsxElement.attributes.properties.some(isKeyAttr)) {
    // this node already has a key! let's get out of here
    return jsxElement;
  }

  const updatedAttributes = ts.factory.createJsxAttributes([
    ts.factory.createJsxAttribute(
      ts.factory.createIdentifier('key'),
      ts.factory.createStringLiteral(deriveJSXKey(jsxElement)),
    ),
    ...jsxElement.attributes.properties,
  ]);

  if (ts.isJsxOpeningElement(jsxElement)) {
    return ts.factory.updateJsxOpeningElement(
      jsxElement,
      jsxElement.tagName,
      jsxElement.typeArguments,
      updatedAttributes,
    );
  } else {
    return ts.factory.updateJsxSelfClosingElement(
      jsxElement,
      jsxElement.tagName,
      jsxElement.typeArguments,
      updatedAttributes,
    );
  }
}

/**
 * Check whether or not a JSX attribute node (well, technically a
 * {@link ts.JsxAttributeLike} node) has the name `"key"` or not
 *
 * @param attr the JSX attribute node to check
 * @returns whether or not this node has the name 'key'
 */
function isKeyAttr(attr: ts.JsxAttributeLike): boolean {
  return !!attr.name && attrNameToString(attr) === 'key';
}

/**
 * Given a JSX attribute get its name as a string
 *
 * @param attr the attribute of interest
 * @returns the attribute's name, formatted as a string
 */
function attrNameToString(attr: ts.JsxAttributeLike): string {
  switch (attr.name?.kind) {
    case ts.SyntaxKind.Identifier:
    case ts.SyntaxKind.PrivateIdentifier:
    case ts.SyntaxKind.StringLiteral:
    case ts.SyntaxKind.NumericLiteral:
      return attr.name.text;
    case ts.SyntaxKind.JsxNamespacedName:
      // this is a JSX attribute name like `foo:bar`
      // see https://facebook.github.io/jsx/#prod-JSXNamespacedName
      return attr.name.getText();
    case ts.SyntaxKind.ComputedPropertyName:
      const expression = attr.name.expression;
      if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)) {
        return expression.text;
      }
      return '';
    default:
      return '';
  }
}
